Linux 系统编程入门:gcc、库、Makefile 与文件 IO
Linux 系统编程从写代码到跑起来要过四道坎:编译、链接库、构建管理、调试;程序运行起来之后,最先打交道的就是文件。本文写给刚从 Windows/IDE 转到 Linux 命令行开发的 C/C++ 程序员,把这条路径上的基础串成一篇:gcc 的分步编译选项、静态库与动态库的制作和加载、Makefile 的规则写法、GDB 常用命令,以及文件 IO 的核心模型——文件描述符、缓冲区层次和 open/read/write/lseek/stat 一族接口。进程创建与进程间通信(fork、exec、管道、信号、共享内存)不在本文范围,见文末系列链接。
gcc 编译
一个 C 源文件到可执行文件要经历预处理、编译、汇编、链接四步,gcc 用选项控制停在哪一步:
gcc [源文件] [-o 输出名]
-E 预处理,输出预处理后的代码,不编译
-S 编译,生成汇编代码
-c 汇编,生成目标文件(.o),不链接
-I directory 指定头文件搜索目录
-g 生成调试信息,供调试器使用
-D 编译时定义一个宏
-l 链接指定的库 g++ 内部同样调用 gcc 后端,但 gcc 编译 C++ 时不会自动链接 C++ 标准库,需要显式加 -lstdc++。
静态库与动态库
库的命名规则:Linux 上静态库 libxxx.a、动态库 libxxx.so;Windows 上对应 libxxx.lib 与 xxx.dll。
制作
静态库:先把源文件编译成目标文件,再用 ar 打包:
gcc -c a.c b.c # 生成 a.o b.o
ar rcs libxxx.a a.o b.o # 打包静态库 动态库:编译时加 -fPIC(位置无关码),再链接为共享对象:
gcc -c -fPIC a.c b.c
gcc -shared a.o b.o -o libxxx.so 使用与加载
链接时用 -l 指定库名(xxx,不带 lib 前缀和扩展名),可以让 gcc 自动找到 libxxx.a/libxxx.so:
gcc main.c -lxxx -L/path/to/lib -o app 静态库在链接期就被复制进可执行文件;动态库在运行期由动态载入器按路径查找加载。运行时找不到动态库是最常见的问题——载入器需要知道库的绝对路径,查找顺序依次是:
- 环境变量
LD_LIBRARY_PATH(临时:在当前终端export;用户级:写入~/.bashrc;注意等号两侧不能有空格) - 配置文件
/etc/ld.so.conf中列出的目录,修改后执行ldconfig刷新/etc/ld.so.cache - 默认目录
/lib、/usr/lib
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/lib 两者取舍:静态库加载快、发布时不依赖外部库文件,但浪费内存且库更新需要重新发布程序;动态库支持进程间共享一份代码、更新部署简单,代价是加载稍慢、发布时要带着依赖。
Makefile
工程源文件多了以后,用 Makefile 描述编译规则实现自动化构建。Makefile 的规则三要素:
目标... : 依赖...
命令 从最直接的写法起步(一条规则列全所有源文件),逐步演进到变量、模式规则和函数,是理解 Makefile 的自然路径:
# 演进一:按目标拆分,修改一个文件只重编它
app: sub.o add.o main.o
gcc sub.o add.o main.o -o app
sub.o: sub.c
gcc -c sub.c -o sub.o
# ...每个 .o 一条规则
# 演进二:自定义变量 + 模式规则,一条规则覆盖所有 .o
src=$(wildcard ./*.c)
objs=$(patsubst %.c, %.o, $(src))
target=app
$(target):$(objs)
$(CC) $(objs) -o $(target)
%.o:%.c
$(CC) -c $< -o $@
.PHONY:clean
clean:
rm $(objs) -f 用到的语法:$(wildcard ./*.c) 收集目录下全部 .c 文件;$(patsubst %.c, %.o, ...) 把列表中的 .c 替换成 .o;$< 是规则中的第一个依赖,$@ 是目标;.PHONY 声明 clean 是伪目标,不与同名文件冲突。
GDB 调试
编译时加 -g 生成调试信息,gdb [可执行文件] 启动。常用命令按调试流程排列:
start 停在 main 第一行
run 运行到第一个断点
b/break 行号|函数名|文件:行号 设置断点
b 10 if i==5 条件断点(循环中常用)
i/info b 查看断点
d/del 编号 删除断点
dis/enable 编号 使断点无效 / en/enable 使生效
n/next 下一行,不进入函数体
s/step 单步,进入函数体
finish 跳出函数体
until 跳出循环
c/continue 继续到下一个断点
p/print 变量 打印变量值
ptype 变量 打印变量类型
display 变量 每步自动打印
set var 变量=值 修改变量值 一个省力细节:不输入命令直接回车,默认重复执行上一条命令(比如连续 next)。
文件 IO
两层缓冲模型
标准 C 库的 IO 函数(fopen/fread/fprintf 一族)调用操作系统的底层接口,可跨平台,且自带用户态缓冲区;Linux 系统调用(open/read/write)不带这层缓冲,数据直接进内核。完整的写入路径:
理解这个层次能解释很多现象:printf 的输出不一定立刻出现在终端(还在用户态缓冲区里);进程崩掉时内核缓冲的数据通常还在(由内核负责写回);而 exit 与 _exit 的区别——前者退出前刷新 stdio 缓冲区,后者直接进系统调用不刷新,不带换行的未输出内容会丢失。
虚拟地址空间与文件描述符
每个进程运行在自己的虚拟地址空间里。32 位 Linux 下低 3G 是用户空间、高 1G 是内核空间,用户空间从低到高依次是代码段、数据段、堆、共享库加载区、栈:
| 区域 | 内容 |
|---|---|
| .text 代码段 | 程序指令 |
| .data 已初始化数据段 | 初始化过的全局/静态变量 |
| .bss 未初始化数据段 | 未初始化的全局/静态变量 |
| 堆 heap | malloc/new 动态分配,向高地址增长 |
| 共享库加载区 | 动态库映射 |
| 栈 stack | 局部变量、函数参数,向低地址增长 |
| 内核空间(高 1G) | 所有进程共享,用户态不可直接访问 |
文件 IO 的桥梁是文件描述符:一个非负整数,进程用它来引用打开的文件。每个进程有一张文件描述符表,0、1、2 默认被标准输入、标准输出、标准错误占用;表项指向内核中统一的文件表(记录打开状态与文件偏移量),文件表再关联到具体的文件:
文件描述符表默认大小为 1024。fork 出的子进程会复制父进程的描述符表,两个进程的描述符指向同一文件表项,因此共享文件偏移量。
open / read / write / close
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 成功返回新的文件描述符,失败返回 -1 并设置 errno
int close(int fd);
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
// 返回实际读/写的字节数;read 返回 0 表示读到文件末尾;失败返回 -1 flags 必选其一:O_RDONLY、O_WRONLY、O_RDWR(互斥);常用可选:O_CREAT(不存在则创建,此时传第三个参数 mode 指定权限,八进制如 0664,最终权限是 mode & ~umask)、O_APPEND(追加)、O_TRUNC(清空)。
C 没有函数重载,两个 open 实际是同一个可变参数原型 open(pathname, flags, ...)。
read/write 的标准用法是循环拷贝:
char buf[1024];
int len;
while ((len = read(srcfd, buf, sizeof(buf))) > 0) {
write(destfd, buf, len);
} 注意 write 的第三个参数用 read 的返回值 len 而不是缓冲区大小——最后一次读往往不足 1024 字节。
出错时用 perror 打印 errno 对应的描述:
if (fd == -1) {
perror("open"); // 输出形如 open: No such file or directory
} lseek 与文件空洞
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
// 返回当前偏移量(相对文件开头,字节),失败返回 -1 whence 三个取值:SEEK_SET(文件开头 + offset)、SEEK_CUR(当前 + offset)、SEEK_END(文件末尾 + offset)。三个惯用法:lseek(fd, 0, SEEK_SET) 移到文件头;lseek(fd, 0, SEEK_CUR) 获取当前位置;lseek(fd, 0, SEEK_END) 获取文件长度。
lseek 允许把偏移量移到超过文件末尾的位置,下次在该位置写入时,原文件末尾与写入点之间就形成文件空洞:逻辑上是文件大小的一部分,读取时得到 \0,但物理上不占磁盘空间( du 显示的实际占用远小于 ls 显示的逻辑大小),直到真正写入时才分配块。
lseek 与 truncate(path, length) 的分工:lseek 移动的是读写位置,不改变文件长度——只 lseek 不写入,文件长度不变;truncate 直接把文件长度截断或扩展到指定值。
stat 获取文件信息
#include <sys/stat.h>
int stat(const char *pathname, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);
// 成功返回 0,失败返回 -1;statbuf 为传出参数 两者区别只在符号链接:stat 返回链接指向文件的信息,lstat 返回链接本身的信息。stat 结构体的常用字段:
struct stat {
dev_t st_dev; // 文件所在设备
ino_t st_ino; // inode 号
mode_t st_mode; // 文件类型与权限
nlink_t st_nlink; // 硬链接数
uid_t st_uid; // 属主
gid_t st_gid; // 属组
off_t st_size; // 文件大小(字节)
blksize_t st_blksize; // 最优 IO 块大小
blkcnt_t st_blocks; // 占用块数(以 512B 计)
time_t st_atime; // 最后访问时间
time_t st_mtime; // 最后修改时间
time_t st_ctime; // 最后状态改变时间
}; st_mode 同时编码文件类型与权限,低 16 位分四段:
| 位段 | 含义 |
|---|---|
| bit 15-12 | 文件类型(用 S_IFMT 掩码提取,与 S_IFREG/S_IFDIR 等比较,或直接用 S_ISREG()/S_ISDIR() 宏) |
| bit 11-9 | 附加位(set-uid、set-gid、sticky) |
| bit 8-6 | 属主权限 rwx |
| bit 5-3 | 属组权限 rwx |
| bit 2-0 | 其他用户权限 rwx |
配套的属性操作函数:access(path, R_OK|W_OK|X_OK|F_OK) 判断权限或存在性、chmod/chown 改权限与属主、truncate 调整长度。
目录操作
#include <dirent.h>
DIR *opendir(const char *name); // 打开目录流
struct dirent *readdir(DIR *dirp); // 逐项读取,末尾或出错返回 NULL
int closedir(DIR *dirp);
#include <sys/stat.h>
int mkdir(const char *path, mode_t mode);
#include <unistd.h>
int chdir(const char *path); // 修改进程工作目录
char *getcwd(char *buf, size_t size); // 获取当前工作目录
int rename(const char *old, const char *new); readdir 返回的 struct dirent 中,d_name 是文件名,d_type 是类型(DT_REG 普通文件、DT_DIR 目录、DT_LNK 符号链接等)。递归统计目录下普通文件数量的骨架:
int count_files(const char *path) {
DIR *dir = opendir(path);
if (dir == NULL) { perror("opendir"); return 0; }
struct dirent *ptr;
int total = 0;
while ((ptr = readdir(dir)) != NULL) {
char *name = ptr->d_name;
if (!strcmp(name, ".") || !strcmp(name, "..")) continue;
if (ptr->d_type == DT_DIR) {
char sub[256];
sprintf(sub, "%s/%s", path, name);
total += count_files(sub); // 目录:递归
} else if (ptr->d_type == DT_REG) {
total++; // 普通文件:计数
}
}
closedir(dir);
return total;
} 注意跳过 . 和 ..,否则递归无法终止。
dup / dup2 / fcntl
#include <unistd.h>
int dup(int oldfd); // 复制描述符,返回最小可用的新 fd,两者指向同一文件表项
int dup2(int oldfd, int newfd); // 复制到指定 newfd(必要时先关闭 newfd),用于重定向
#include <fcntl.h>
int fcntl(int fd, int cmd, ...); // F_DUPFD 复制 / F_GETFL 取状态 / F_SETFL 设状态 dup2 是 shell 重定向的原理:dup2(fd, STDOUT_FILENO) 之后,写标准输出实际写到 fd 指向的文件。
fcntl 常用于运行期改打开状态,比如给已打开的文件追加 O_APPEND 或设置非阻塞:
int flag = fcntl(fd, F_GETFL);
flag |= O_APPEND;
fcntl(fd, F_SETFL, flag); 访问模式(O_RDONLY/O_WRONLY/O_RDWR)不能通过 F_SETFL 修改。
要点
- 编译分四步,
-E/-S/-c分别停在各步;链接库用-l加库名(不含 lib 前缀与扩展名) - 动态库运行期加载失败找
LD_LIBRARY_PATH、ld.so.conf + ldconfig、默认目录三条路 - C 库 IO 有用户态缓冲、系统调用直通内核缓冲;
exit刷缓冲、_exit不刷 - 文件描述符是进程内的小整数,经文件描述符表 → 文件表 → inode 三层间接引用到文件;fork 后父子进程共享文件偏移量
- read 返回 0 即文件末尾;write 长度用实际读到的字节数;lseek 只移位置不改长度,truncate 改长度
- 进程创建与 IPC 的配套内容:fork/exec/进程回收见 Linux 多进程开发:fork、exec 与进程回收,管道、信号、共享内存、守护进程见各篇(文末目录可索引)